page.tsx 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. import './style.scss';
  2. import '@/app/styles/stock-common.scss';
  3. import { cache } from 'react';
  4. import { Metadata } from 'next';
  5. import Link from 'next/link';
  6. import { notFound } from 'next/navigation';
  7. import { fetchStockDetail, fetchStockHistory } from '@/lib/api/stock';
  8. import { fetchStockDisclosures } from '@/lib/api/market';
  9. import { fetchStockBoardPosts } from '@/lib/api/community';
  10. import { changeDirection, changeSymbol, formatChangeRate, formatKrwCompact, formatNumber, tradingStatusLabel } from '@/lib/utils/stock';
  11. import Pager from '../_components/Pager';
  12. import DisclosureList from '../../market/_components/DisclosureList';
  13. import StockDiscussion from './_components/StockDiscussion';
  14. const TABS = [
  15. { value: 'price', label: '시세' },
  16. { value: 'news', label: '뉴스' },
  17. { value: 'disclosure', label: '공시' },
  18. { value: 'discussion', label: '토론' }
  19. ];
  20. const HISTORY_PER_PAGE = 30;
  21. const DISCUSSION_PER_PAGE = 20;
  22. type Props = {
  23. params: Promise<{
  24. code: string;
  25. }>;
  26. searchParams: Promise<{
  27. tab?: string;
  28. page?: string;
  29. }>;
  30. };
  31. // generateMetadata + 본문 이중 호출 방지 (요청 단위 dedupe)
  32. const getDetail = cache(async (code: string) => {
  33. return await fetchStockDetail(code);
  34. });
  35. export async function generateMetadata({ params }: Props): Promise<Metadata>
  36. {
  37. const { code } = await params;
  38. const res = await getDetail(code);
  39. if (!res.success || !res.data) {
  40. return {
  41. title: '종목 상세 — 개미투자'
  42. };
  43. }
  44. return {
  45. title: `${res.data.name}(${res.data.code}) 주가·시세 — 개미투자`,
  46. description: `${res.data.name}(${res.data.code}) 전일 종가·등락률·거래량·일별 시세 — 전일 종가 기준(T+1)`
  47. };
  48. }
  49. export default async function StockDetailPage({ params, searchParams }: Props)
  50. {
  51. const { code } = await params;
  52. const query = await searchParams;
  53. const tab = TABS.some(c => c.value === query.tab) ? (query.tab as string) : 'price';
  54. const page = Math.max(Number(query.page) || 1, 1);
  55. const res = await getDetail(code);
  56. // 없는 코드(형식 불일치 포함)는 404
  57. if (!res.success && res.status === 404) {
  58. return notFound();
  59. }
  60. // 백엔드 미기동 등 그 외 실패 — 에러 UI 로 우아하게 처리
  61. if (!res.success || !res.data) {
  62. return (
  63. <div id='stock-detail'>
  64. <div className='stock-detail__error' role='alert'>
  65. <strong>종목 정보를 불러오지 못했습니다.</strong>
  66. 잠시 후 다시 시도해 주세요.
  67. </div>
  68. </div>
  69. );
  70. }
  71. const detail = res.data;
  72. const latest = detail.recentPrices.length > 0 ? detail.recentPrices[0] : null;
  73. const direction = changeDirection(detail.changeRate);
  74. const statusLabel = tradingStatusLabel(detail.tradingStatus);
  75. // 시세 탭만 히스토리 조회 (request-time)
  76. const history = tab === 'price' ? await fetchStockHistory(code, { page, perPage: HISTORY_PER_PAGE }) : null;
  77. // 공시 탭 — 종목별 DART 공시 조회 (request-time)
  78. const disclosures = tab === 'disclosure' ? await fetchStockDisclosures(code, { page }) : null;
  79. // 토론 탭 — 종목 가상보드 글 목록 (request-time)
  80. const discussion = tab === 'discussion' ? await fetchStockBoardPosts(code, { page, perPage: DISCUSSION_PER_PAGE }) : null;
  81. return (
  82. <div id='stock-detail'>
  83. {/* 상단 요약 — T+1 데이터임을 명시 */}
  84. <header className='stock-detail__summary'>
  85. <div className='stock-detail__title'>
  86. <h1>{detail.name}</h1>
  87. <span className='stock-detail__code'>{detail.code}</span>
  88. <span className={`stock-badge stock-badge--${detail.market.toLowerCase()}`}>{detail.market}</span>
  89. {statusLabel && (
  90. <span className={`stock-badge stock-badge--${detail.tradingStatus.toLowerCase()}`}>{statusLabel}</span>
  91. )}
  92. {detail.sectorName && <span className='stock-detail__sector'>{detail.sectorName}</span>}
  93. </div>
  94. <div className='stock-detail__price'>
  95. <strong className={`stock-detail__close stock-change stock-change--${direction}`}>
  96. {formatNumber(detail.closePrice)}
  97. </strong>
  98. <span className={`stock-detail__diff stock-change stock-change--${direction}`}>
  99. {changeSymbol(direction)} {latest ? formatNumber(Math.abs(latest.changeAmount)) : '—'} ({formatChangeRate(detail.changeRate)})
  100. </span>
  101. </div>
  102. <dl className='stock-detail__meta'>
  103. <div>
  104. <dt>거래량</dt>
  105. <dd>{formatNumber(latest?.volume)}</dd>
  106. </div>
  107. <div>
  108. <dt>거래대금</dt>
  109. <dd>{formatKrwCompact(latest?.tradingValue)}</dd>
  110. </div>
  111. <div>
  112. <dt>시가총액</dt>
  113. <dd>{formatKrwCompact(detail.marketCap)}</dd>
  114. </div>
  115. </dl>
  116. <p className='stock-detail__basis'>
  117. 전일 기준 · {detail.lastTradingDate ?? '데이터 준비 중'} 종가 (T+1 반영)
  118. </p>
  119. </header>
  120. {/* 탭 */}
  121. <nav className='stock-detail__tabs' aria-label='종목 상세 탭'>
  122. {TABS.map(item => (
  123. <Link
  124. key={item.value}
  125. href={item.value === 'price' ? `/stock/${detail.code}` : `/stock/${detail.code}?tab=${item.value}`}
  126. {...(tab === item.value ? { 'aria-current': 'page' as const } : {})}
  127. >
  128. {item.label}
  129. </Link>
  130. ))}
  131. </nav>
  132. <section className='stock-detail__panel'>
  133. {tab === 'disclosure' ? (
  134. !disclosures || !disclosures.success || !disclosures.data ? (
  135. <div className='stock-detail__error' role='alert'>
  136. <strong>공시를 불러오지 못했습니다.</strong>
  137. 잠시 후 다시 시도해 주세요.
  138. </div>
  139. ) : (
  140. <>
  141. <DisclosureList rows={disclosures.data.list} />
  142. <Pager
  143. total={disclosures.data.total}
  144. page={page}
  145. perPage={HISTORY_PER_PAGE}
  146. basePath={`/stock/${detail.code}`}
  147. query={{ tab: 'disclosure' }}
  148. />
  149. </>
  150. )
  151. ) : tab === 'discussion' ? (
  152. !discussion || !discussion.success || !discussion.data ? (
  153. <div className='stock-detail__error' role='alert'>
  154. <strong>토론을 불러오지 못했습니다.</strong>
  155. 잠시 후 다시 시도해 주세요.
  156. </div>
  157. ) : (
  158. <StockDiscussion
  159. code={detail.code}
  160. stockName={detail.name}
  161. page={page}
  162. perPage={DISCUSSION_PER_PAGE}
  163. data={discussion.data}
  164. />
  165. )
  166. ) : tab !== 'price' ? (
  167. <p className='stock-detail__placeholder'>준비 중입니다.</p>
  168. ) : !history || !history.success || !history.data ? (
  169. <div className='stock-detail__error' role='alert'>
  170. <strong>일별 시세를 불러오지 못했습니다.</strong>
  171. 잠시 후 다시 시도해 주세요.
  172. </div>
  173. ) : history.data.list.length === 0 ? (
  174. <p className='stock-detail__empty'>수집된 일별 시세가 없습니다.</p>
  175. ) : (
  176. <>
  177. <table className='stock-detail__table'>
  178. <caption>{detail.name} 일별 시세 — 날짜, 종가, 등락, 시가, 고가, 저가, 거래량, 거래대금</caption>
  179. <thead>
  180. <tr>
  181. <th scope='col'>날짜</th>
  182. <th scope='col'>종가</th>
  183. <th scope='col'>등락률</th>
  184. <th scope='col' className='stock-detail__col--sub'>시가</th>
  185. <th scope='col' className='stock-detail__col--sub'>고가</th>
  186. <th scope='col' className='stock-detail__col--sub'>저가</th>
  187. <th scope='col'>거래량</th>
  188. <th scope='col' className='stock-detail__col--sub'>거래대금</th>
  189. </tr>
  190. </thead>
  191. <tbody>
  192. {history.data.list.map(row => {
  193. const rowDirection = changeDirection(row.changeRate);
  194. return (
  195. <tr key={row.tradingDate}>
  196. <td>{row.tradingDate}</td>
  197. <td>{formatNumber(row.close)}</td>
  198. <td className={`stock-change stock-change--${rowDirection}`}>
  199. {changeSymbol(rowDirection)} {formatChangeRate(row.changeRate)}
  200. </td>
  201. <td className='stock-detail__col--sub'>{formatNumber(row.open)}</td>
  202. <td className='stock-detail__col--sub'>{formatNumber(row.high)}</td>
  203. <td className='stock-detail__col--sub'>{formatNumber(row.low)}</td>
  204. <td>{formatNumber(row.volume)}</td>
  205. <td className='stock-detail__col--sub'>{formatKrwCompact(row.tradingValue)}</td>
  206. </tr>
  207. );
  208. })}
  209. </tbody>
  210. </table>
  211. <Pager
  212. total={history.data.total}
  213. page={page}
  214. perPage={HISTORY_PER_PAGE}
  215. basePath={`/stock/${detail.code}`}
  216. query={{}}
  217. />
  218. </>
  219. )}
  220. </section>
  221. </div>
  222. );
  223. }